Own 3D file format or Exporter plug-in for 3DS Max.
Using MaxSDK.

By rand0m

Note: for understating this material it is necessary to know foundations of OOP in C++, Windows programming of Dynamic Link Libraries (DLL), and foundations of work in 3DS max. Also you need to know foundations of 3D graphics and mathematics. For writing plug-ins for 3DS Max it is necessary to have: 3DS Max 3.x or higher with MaxSDK, Visual C++ 6.0. MaxSDK is supplied on the CD with 3DSMax.

Part I

Intro: How I decided to develop my own 3D file format

I had to make a program for a company, for demonstrating their project. It is a project for saving the Aral Sea, which is located in our country. So it was a 3D demo of their project about the Aral Sea.

I turned the music on loud and began.

I had a program (written by myself, following the documentation) that reads Discreet 3ds file format. But it wasn't suitable for me because of these reasons:

1. It doesn't have two or more texture cords.
2. It doesn't keep more than 64535 faces.
3. It's terrible and uncomfortable for reading.

I searched through the Internet for something suitable for my needs, but I didn't found anything. All formats have only one texture coordinate.

So I decided to develop my own file format. I named it VX and did it like this:

Listing 1: File format - VXLFormat.h (C++ header)

// VXLFormat.h

typedef unsigned char  byte; //types definition
typedef unsigned short word;
typedef unsigned long  dword; 

// for type of texture
#define VXM_BUMP 1 //texture is a bumpmap
#define VXM_REFL 2 //texture is a reflectmap

typedef struct
{
        word signature;
        int numObjects;
        int numFrames;
        int numRecords;
} VXHeader; //header of file

typedef struct
{
        byte Type; //Type of second texture
        char Texture1[15]; //filename of first texture
        char Texture2[15]; // filename of second texture
        bool HaveTexture1; //material has first texture
        bool HaveTexture2; //material has second texture
} VXMaterial; //record for material

typedef struct
{
        float  uv[2]; //first texture coordinates
        float uv2[2]; //second texture coordinates
} VXTexCoord; //record for two texture coordinates

typedef struct
{
        dword Offset;    // offset in file for this object
        dword numRecords; //number of vertexes
        dword numFaces; //number of faces
        char Name[15]; //name of object
        VXTexCoord  *TexCoords; //array of texture coordinates
} VXObject;  //record for object

typedef struct 
{
        float   n[3]; //normal
        float   v[3]; //vertex
} VXRecord; //record for vertex coordinates and normal

class VXFile //class for working with file
{
        private:
//has no private :)
        public:
                VXFile::VXFile();
                VXFile::~VXFile();

                //some working stuff
        	bool circled;
                unsigned int objNum, selected;
                dword tempOffset;
                
                VXHeader     Head; //my header
                VXMaterial  *Materials; //array of materilas
                VXObject    *Objects;   //array of objects
                VXRecord    *Records;   //array of records
                bool LoadVXFile(char *name); //procedure for loading file into structures
                bool SaveVXFileFrom3DMax(ofstream * eFile);  //void for exporter
 };

You see? It's very easy, but it is suitable for using.

I think I must give some explanations for this.

First, why do I put the array of texture coordinates into the VXObject structure, and put vertex and normal data in a common array?

Because I wanted to add animation in future versions, so I decided to do so. You see in animation vertex and normal data are changing during the time line, and texture coordinates data don't change.

And here is the listing of procedures of this class:

Listing 2: Class subroutines - VXLFormat.cpp (C++ cpp)

#include "VXLFormat.h" 
#include <fstream.h>


bool VXFile::LoadVXFile(char *name)
{
        ifstream        fFile;

        fFile.open(name, ios::in | ios::binary);  

        fFile.read((char *)&Head, sizeof(Head));
        
        Materials = new VXMaterial[Head.numObjects+1]; //creating materials array
        Objects = new VXObject[Head.numObjects+1];     //creating objects array

//reading materials
        fFile.read((char *)Materials, Head.numObjects*sizeof(VXMaterial)); 
   
        //reading objects
        for (int i=0; i<Head.numObjects; i++)
        {
                fFile.read((char *)&Objects[i], 21);
                Objects[i].TexCoordinates = new VXTexCoord[Objects[i].numRecords];      
                fFile.read((char *)&Objects[i].TexCoordinates[0], sizeof(VXTexCoord)*Objects[i].numRecords);
        }
        
        Records = new VXRecord[Head.numRecords+1]; //creating vertexes and normals array
   // reading array
        fFile.read((char *)Records, Head.numRecords*sizeof(VXRecord));
        
        fFile.close();
        return true;
}
VXFile::VXFile() //constructor
{
        Head.numFrames = 0;
        Head.numObjects = 0;
        Head.numRecords = 0;
        tempOffset = 0;
        circled = false;
        objNum  = 0;
        selected =0;
}


VXFile::~VXFile() //destructor
{

        Head.numFrames = 0;
        Head.numObjects = 0;
        Head.numRecords = 0;
        tempOffset = 0;
        circled  = false;
        objNum   = 0;
        selected = 0;
        delete Materials;
        delete Objects;
        delete Records; 
}

And now you must see why it is suitable for using: all the data is located in order, and reading it is very easy. After you loaded the file, you may use it fast and easily both in DirectX and in OpenGL. It is very comfortable for buffer working, like in DirectX (in OpenGL it is possible only if the GL_EXT_vertex_array extension is supported by your video card).

Part II

Exporter: How can I write 3D models in a file of my own format?

Yeah, I thought about it too. I had three ways: to write my own 3D modeling program, to write a converter of another format and use one of the existing 3D programs. I chose the second way. I decided to write a plug-in for 3DS max 5.0 to which I had access, and began to study the construction of 3DS Max.

So I found:

1. 3DS Max has MaxSDK.
2. All plug-ins of 3DS Max are located in the \plug-ins\ directory.
3. MaxSDK has help and something named "Sparks Archive"

So, what are those things? - you'll ask me. MaxSDK is 3DS Max Software Development Kit - set of libraries and C++ header files for compiling plug-ins for 3DS max. As written in the SDK's help: "The 3ds max Software Development Kit (SDK) is an object-oriented programming library for creating plug-in applications for 3ds max. The SDK provides a comprehensive set of classes that developers can combine and extend to create seamlessly integrated plug-in applications. Using the SDK one can create a great variety of plug-ins. In fact, much of 3ds max itself is written as plug-in applications."

Sparks Archive is something like FAQ on MaxSDK, there is a lot of useful information.

It has a set of examples for help in developing too. All plug-ins for 3DS Max are DLL (Windows Dynamic Link Library), but extensions of their filenames are different from DLL. All plug-ins are determined by extensions, so:

*.dli - plug-ins for import

*.dle - plug-ins for export

*.dlc - controller plug-ins

*.dlu - utility plug-ins
*.dlm - modifier plug-ins

and so on.

So plug-in for 3DS Max is DLL with corresponding extension, but then I know that the file extension isn't very important for 3DS Max to determine the type of plug-in.

Exporter: Internal structure of Max's plug-in

Let's go into the \maxsdk\samples\impexp\ directory and look at it. It contains sources of plug-ins for import/export for Max. It contains the following sources:

objimp.dsw - MSVC project for importing *.obj file
dxfimp.dsw - MSVC project for importing AutoCAD *.dfx file
dxfexp.dsw - MSVC project for exporting AutoCAD *.dfx file
aiimp.dsw - MSVC project for importing *. ai file
aiexp.dsw - MSVC project for exporting *. ai file
3dsimp.dsw - MSVC project for importing Discreet *.3ds file
3dsexp.dsw - MSVC project for exporting Discreet *.3ds file

We need a plug-in for export, so let's open 3dsexp.dsw and look at it attentively. I know you will say: "What the #@$% is this?!?!?! I don't understand anything." But there is nothing complicated.

Let's examine the structure of a plug-in. From C++ point of view, a plug-in for Max is an object of a special class that is stored in a DLL. Any DLL may store more that one class. Let's have a good look at the subroutines:

- DllMain() - standard windows function for DLL initializing.
- LibNumberClasses() - than function returns the number of classes stored in this DLL.
- LibVersion() - returns the version of Max for which the plug-in is.
- LibDescription() - returns plug-in description string
- LibClassDesc() - that's the most important function. It returns a pointer to the object, which is named descriptor of class for every plug-in in that DLL. That object describes the properties of every plug-in class and the way of its creating.

So let's open Microsoft Visual C++ 6.0 and create a new C++ Win32 DLL project. Name it MyPlugin.

Listing 3: 3DS Max plug-in - MyPlugin.cpp

#include "MyPlugin.h"

HINSTANCE               hInstance;
bool                    ControlsInit = false;

//Standart DLL entry point
BOOL APIENTRY DllMain(HINSTANCE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
  hInstance = hModule;
  if (!ControlsInit)
  {
    ControlsInit = true;
    InitCustomControls(hInstance);
    InitCommonControls();
  }
    return TRUE;
}

// than function returns number of classes stored in this DLL 
__declspec(dllexport) int LibNumberClasses()
{
  return 1;
}


// returns pointer to the description class number i
__declspec(dllexport) ClassDesc *LibClassDesc(int i)
{
  switch (i)
  {
  case 0: return &PluginDesc; 
  default: return 0;
  }
}


//returns description
__declspec(dllexport) const TCHAR *LibDescription()
{
  return _T(GetString(IDS_PLUG-IN_NAME)); //gets it from string list resource
}

//returns version
__declspec(dllexport) ULONG LibVersion()
{
  return VERSION_3DSMAX;
}

The next listing will be a little complicated. It is the file MyPlugin.h. There we have a description of DescripionClass, exporter class and special for exporters - SceneSaver. There it is:

Listing 4: 3DS Max plug-in - MyPlugin.h

#ifndef _MY_MAX_PLUG_
#define _MY_MAX_PLUG_

#include <windows.h>
#include <Max.h>  //don't forget to include Max.h
#include <fstream.h>
#include <stdmat.h>
#include <string>

#include "resources.h"

#define MYEXP_CLASS_ID Class_ID(0x70af5fa1, 0x49f75422)
//unique identificator for every plug-in, 
//it can be generated by gencid.exe utility in the \maxsdk\help\ directory.

static TCHAR* GetString(int id)
{
    static TCHAR stBuf[255];
        if (hInstance)
                return LoadString(hInstance, id, stBuf, 255) ? stBuf : NULL;
        return NULL;
}

class MyExp : public SceneExport  
//exporter class, inherited from SceneExport from MaxSDK library
{
        friend INT_PTR CALLBACK ExportOptionsDlgProc(HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam);
        
public:
        int        ExtCount()      { return 1; }
        const TCHAR*  Ext(int i)      
                { if (i == 0) return _T(GetString(IDS_PLUG-IN_EXT1)); else return _T(""); }
        const TCHAR*  LongDesc()      { return _T(GetString(IDS_LONG_DESC)); }
        //returns long description of plugin
        const TCHAR*  ShortDesc()      { return _T(GetString(IDS_SHORT_DESC)); }
        //returns short description of plugin
        const TCHAR*  AuthorName()    { return _T(GetString(IDS_AUTHOR_NAME)); }
        //returns author name
        const TCHAR*  CopyrightMessage()  { return _T(GetString(IDS_COPYRIGHT)); }
        //returns copyright message     
	const TCHAR*  OtherMessage1()    { return _T(""); }
        const TCHAR*  OtherMessage2()    { return _T(""); }
        unsigned int  Version()      { return 100; }
        // returns version in xx.xx * 100 format, if version is 1.00 it must return 100
        void      ShowAbout(HWND hWnd){ MessageBox(hWnd, GetString(IDS_ABOUT), "About", MB_OK); }
        //shows about box
        BOOL      SupportsOptions(int ext, DWORD options) {return 1;} 
	//supported options
        
        //============= that function does export===========================
        int        DoExport(const TCHAR *name, ExpInterface *ei,
                Interface *i, BOOL suppressPromts = FALSE, DWORD options = 0);
        MyExp() {;};
        virtual ~MyExp() {;}
};

class DescripionClass : public ClassDesc  //description class
{
public:
        int      IsPublic() { return 1; }//is it for user? :)
        void*      Create(BOOL Loading = FALSE) { return new MyExp; } 
//returns pointer to the new class object
        const TCHAR  *  ClassName() { return _T(GetString(IDS_CLASS_NAME)); } 
//reurns class name
        SClass_ID    SuperClassID() { return SCENE_EXPORT_CLASS_ID; } 
        //returns SCENE_EXPORT_CLASS_ID - means that this is exporter
        Class_ID    ClassID() { return MYEXP_CLASS_ID; } 
//returns unique class id

        const TCHAR  *  Category() { return _T(""); }
//in what toolbar it has it's button (it hasn't)
};

static DescripionClass  PluginDesc; //object which returns LibClassDesc() function

typedef std::string remString; //temporary-work string

class SceneSaver: public ItreeEnumProc //that class is for saving Max's scene to file
{
public:
        
        // Main functions
        int callback(INode *node);  //that function is called for export
        void ProcNode(INode *node); //that function is called for process 
                                    //one node from scene
};

// Standalone functions
TriObject       *GetTriObjFromNode(INode *node, int &deleteIt);
//gets triangle mesh from Max's node
remString       ExtractFileName(remString filename);

#endif

I think there can't be any questions except "What's a class SceneSaver, a class ItreeEnumProc and their functions?" All these things I will tell you in the next listing.

So how does the exporter work? First of this I want to tell you some theory about the architecture of Max scene. Every object on the Max scene, in code, presents itself as a geometric conveyor that has a base geometric object in its foundation and then turns to the modified object in the output. For every object there is a link to this conveyor, which called node. With help of this node we can get all information about this object: material, result mesh (called TriObject), etc. From mesh we can receive vertex coordinates, normals and so on. If you want to learn more about it you can read about geometry pipeline system of Max in MaxSDK help. So for exporting scene we must parse all of scene nodes and take only suitable information.

We also will need information about how Max stores geometry and texture coordinates.

First we will take TriObject from node, then from TriObject we can get all geometric data.

So - the last listing:

Listing 5: 3DS Max plug-in - Exporter.cpp

#include "MyPlugin.h" //using our plug-in header
#include "VXLFormat.h" //using our 3D file-format class

Interface *interf; //this is interface from what we're gonna get nodes

static SceneSaver TreeEnum;  //enumerator of scene from which called callback function
static ofstream   fFile; //our file for writing

static VXFile expFile;      // our format file

bool VXFile::SaveVXFileFrom3DMax(ofstream * eFile) //our function for writing file
{
        TCHAR buf[255];

        Head.signature  = 1;
        Head.numFrames  = 0;
        
        fFile.write((char *)&Head, sizeof(Head));
        fFile.write((char *)Materials, Head.numObjects*sizeof(VXMaterial));

        for (int i=0; i<Head.numObjects; i++)
        {
                fFile.write((char *)&Objects[i], 21);
                fFile.write((char *)Objects[i].TexCoords, Objects[i].numRecords*sizeof(VXTexCoord));
                for (int j=0; j<Objects[i].numRecords; j++)
                {
                        sprintf(buf, "\n\nN: %d U: %f; V: %f;", j, Objects[i].TexCoords[j].uv[0], 
                                Objects[i].TexCoords[j].uv[1]);
                        AddToMsgList(msgList, buf);
                }
                
        }

        
        fFile.write((char *)Records, Head.numRecords*sizeof(VXRecord));

        Head.numFrames = 0;
        Head.numObjects = 0;
        Head.numRecords = 0;
        tempOffset = 0;
        circled = false;
        objNum  = 0;
        selected =0;

        return true;
}


//that function does parsing the interface for scene
// I used two times parsing because first I want to know number of objects
// and then I'll get all data 
int MyExp::DoExport(const TCHAR *name, ExpInterface *ei, Interface *i, 
                    BOOL suppressPromts , DWORD options )
{

        fFile.open(name, ios::out | ios::binary); 

        interf = i;     
        
        expFile.circled = false; //it is first parse or second?

        ei->theScene->EnumTree(&TreeEnum); 
//at this time called function callback, which is call ProcNode
// and we count number ofobjects in scene

        expFile.circled = true;
        //recording all data in our class strucure
        expFile.Head.numObjects = expFile.objNum; 
        expFile.Objects = new VXObject[expFile.objNum+1];
        expFile.Materials = new VXMaterial[expFile.objNum+1];
        
        expFile.Records = new VXRecord[expFile.tempOffset+1];
        
        expFile.Head.numRecords = expFile.tempOffset; 

        expFile.tempOffset = 0;
        
//do parsing second time
        ei->theScene->EnumTree(&TreeEnum);
        
        //save file
        expFile.SaveVXFileFrom3DMax(&fFile);
        //close file
        fFile.close();
        return 1;
}

//this procedure called when scene interface calls EnumTree 
int SceneSaver::callback(INode *node) {
        ProcNode(node); //call  ProcNode
        return TREE_CONTINUE;
}

void SceneSaver::ProcNode(INode *node) //parse all data
{
        int                     numF, numV, numTV, CurrHeader, zero = 0, debug = 0xAA, Del;
        streampos       NextNode, VertexPointer, FacePointer, TmpPos;
        TCHAR buf[255];
        Matrix3 tm;
        Point3 v;

        // Get TriObject from node
        TriObject *TObj;                                
        TObj = GetTriObjFromNode(node, Del);

        
        if (!expFile.circled) //if it is first parse
        {
                expFile.objNum++;
                expFile.tempOffset+=(3*TObj->mesh.numFaces);
                TObj->mesh.buildNormals(); //to build normals

        }
        else                       //if it is second parse
        {
                if (!TObj) return;
                numF = TObj->mesh.numFaces; //got number of faces
                numV = TObj->mesh.numVerts; //got number of faces
                numTV = TObj->mesh.numTVerts; //got number of texture coordinates
                expFile.Objects[expFile.selected].numRecords  = numF*3;
                expFile.Objects[expFile.selected].numFaces    = numF;
                
                sprintf(expFile.Objects[expFile.selected].Name, node->GetName());
                //fot object name               

                expFile.Objects[expFile.selected].TexCoords = 
                  new VXTexCoord[expFile.Objects[expFile.selected].numRecords];

                tm = node->GetObjTMAfterWSM(interf->GetTime());
                // this line does very important thing - 
                // it took object transformation matrix after all modifiers applied
                // in current frame$
                // besides to export animated object you may do many parses
                // and change time every time

                Point3  nCoord;
                
                expFile.Objects[expFile.selected].Offset = expFile.tempOffset; 
                //calculate offset in file for vertices and normals data
                
                for (int i = 0; i < TObj->mesh.numFaces; i++)
                {
                        for (int j=0; j<3; j++)
                        {       
                                Face face     = TObj->mesh.faces[i];
                                TVFace tvFace = TObj->mesh.tvFace[i];
                                
                                v = tm * TObj->mesh.verts[TObj->mesh.faces[i].v[j]];
                                v = v/100;
                                expFile.Records[expFile.tempOffset].v[0] = v.x;
                                expFile.Records[expFile.tempOffset].v[1] = v.z;
                                expFile.Records[expFile.tempOffset].v[2] = v.y;
                                
                                nCoord = TObj->mesh.getNormal(TObj->mesh.faces[i].v[j])/100;
                                expFile.Records[expFile.tempOffset].n[0] = nCoord.x;
                                expFile.Records[expFile.tempOffset].n[1] = nCoord.z;
                                expFile.Records[expFile.tempOffset].n[2] = nCoord.y;
                                
                                UVVert tvert  = TObj->mesh.getTVert(tvFace.t[j]);
                                if (TObj->mesh.numTVerts != 0)
                                {
                                        expFile.Objects[expFile.selected].TexCoords[expFile.tempOffset].uv[0] 
                                          =  tvert.x;
                                        expFile.Objects[expFile.selected].TexCoords[expFile.tempOffset].uv[1] 
                                          =  tvert.y;
                                }
                                
                                expFile.tempOffset ++; 
                        }
                }
//Got all triangle object data

                // Get diffuse material texture name
                Mtl *m = node->GetMtl();
                if (!m) return;
                // See if it's a standart material
                if (m->ClassID() != Class_ID(DMTL_CLASS_ID, 0)) 
                {
                        expFile.Materials[expFile.selected].HaveTexture1 = false;
                        expFile.Materials[expFile.selected].HaveTexture2 = false;
                }
                Texmap *tmap = m->GetSubTexmap(ID_DI);
                if ((!tmap)||(tmap->ClassID() != Class_ID(BMTEX_CLASS_ID, 0))) 
                {
                        expFile.Materials[expFile.selected].HaveTexture1 = false;
                }
                else
                {
                        expFile.Materials[expFile.selected].HaveTexture1 = true;
                        // If bitmap exists
                        BitmapTex *bmt = (BitmapTex *)tmap;
                        // Write name of bitmap to file;
                        strcpy((char *)expFile.Materials[expFile.selected].Texture1, 
                          ExtractFileName(bmt->GetMapName()).data());

                }
                if (Texmap *tmap = m->GetSubTexmap(ID_BU)) //is it bumpmap?
                {
                        expFile.Materials[expFile.selected].HaveTexture2 = true;
                        // If bitmap exists
                        BitmapTex *bmt = (BitmapTex *)tmap;
                        // Write name of bitmap to file;
                        strcpy((char *)expFile.Materials[expFile.selected].Texture2, 
                          ExtractFileName(bmt->GetMapName()).data());
                        expFile.Materials[expFile.selected].Type = VXM_BUMP;

                } else if (Texmap *tmap = m->GetSubTexmap(ID_RL)) //is it reflect map?
                {
                        expFile.Materials[expFile.selected].HaveTexture2 = true;
                        // If bitmap exists
                        BitmapTex *bmt = (BitmapTex *)tmap;
                        // Write name of bitmap to file;
                        strcpy((char *)expFile.Materials[expFile.selected].Texture2, 
                          ExtractFileName(bmt->GetMapName()).data());
                        expFile.Materials[expFile.selected].Type = VXM_REFL;

                } 

                expFile.selected++;
        }
}

//function for taking triangle object from scene node
TriObject *GetTriObjFromNode(INode *node, int &deleteIt)
{
        deleteIt = FALSE;
        Object *obj = node->EvalWorldState(interf->GetTime()).obj;
        if (obj->CanConvertToType(Class_ID(TRIOBJ_CLASS_ID,0)))
        {
                TriObject *tri = (TriObject *) obj->ConvertToType(interf->GetTime(), 
                        Class_ID(TRIOBJ_CLASS_ID, 0));
                if (obj != tri) deleteIt = TRUE;
                return tri;
        }
        else return NULL;
}

//function for extracting filename from full path 
remString ExtractFileName(remString filename)
{
        if (filename.size() == 0) return "";
        
        int i = filename.size();
        remString buf;
        while((filename[i] != '\\') && (i > 0))
        {
                buf = filename[i--] + buf;
        }
        return buf;
}

I think that's all for this time. I tried to explain this topic very simplified. For any questions, comments, bug reports, opinions,... contact me.

rand0m